第 6 章 Spring Boot 整合缓存
写给新手的话:
上一章我们学了 Redis,知道了缓存的概念。这一章我们来学 Spring Boot 怎么优雅地使用缓存。
你可能会问:上一章不是已经学了 RedisTemplate 操作缓存了吗?为什么还要学这个?
答案是:声明式缓存更优雅。
用 RedisTemplate 的话,你得在业务代码里手动写缓存逻辑:先查缓存、没有再查数据库、然后放缓存……
用声明式缓存的话,你只要加一个注解,Spring 就自动帮你搞定了。业务代码干干净净,看不到缓存逻辑。
学习建议:
- 重点掌握几个核心注解的用法
- 理解缓存的工作原理
- 知道不同缓存实现的区别
- 了解缓存常见问题和解决方案
准备好了吗?我们开始吧!
6.1 先搞明白:缓存那些事儿
6.1.1 什么是缓存
缓存,就是把数据存到一个更快的地方,下次用的时候直接从快的地方拿,不用再去慢的地方取。
打个比方:
- 你平时用的书,放在书桌上(缓存),随手就能拿到
- 不常用的书,放在书架上(数据库),要拿得走过去
- 书桌空间有限(缓存容量小),书架空间大(数据库容量大)
- 书桌拿书快(缓存速度快),书架拿书慢(数据库速度慢)
这就是缓存的核心思想:空间换时间。
6.1.2 为什么需要缓存
为什么要加缓存?直接查数据库不行吗?
行,但是有问题:
- 数据库性能瓶颈:数据库读写是有上限的,高并发场景下扛不住
- 响应慢:每次都查数据库,磁盘 IO 慢,用户体验差
- 重复计算:有些数据计算很费时间,每次都重新算不划算
- 数据库压力大:所有请求都打在数据库上,数据库容易挂
加了缓存之后:
- 读请求先查缓存,有就直接返回,速度快很多
- 缓存没有才查数据库,然后放到缓存里
- 数据库压力大大降低
- 响应速度提升几个数量级
举个例子:
- 查数据库:100ms
- 查 Redis:1ms
- 差了 100 倍!
而且数据库并发能力有限,Redis 能扛几万 QPS。
6.1.3 缓存的适用场景
适合用缓存的:
- 读多写少的数据(商品信息、用户信息、文章内容)
- 热点数据(首页、排行榜)
- 计算成本高的数据(统计报表、复杂查询结果)
- 不要求强一致性的数据
不适合用缓存的:
- 写多读少的数据
- 要求强一致性的数据(钱、库存)
- 数据更新特别频繁的
- 数据量特别大且访问没规律的(缓存命中率低)
6.1.4 Spring Boot 缓存抽象
Spring 提供了一套缓存抽象(Cache Abstraction),统一了各种缓存技术的使用方式。
就像 JDBC 统一了各种数据库的访问方式一样,Spring Cache 统一了各种缓存技术的使用方式。
核心思想:
- 你用注解来声明缓存逻辑
- Spring 自动帮你处理缓存的读写
- 底层缓存实现可以切换:默认缓存、Ehcache、Redis、Caffeine 等
- 切换实现不用改业务代码,改配置就行
支持的缓存实现:
| 缓存实现 | 特点 | 适用场景 |
|---|---|---|
| 默认(ConcurrentHashMap) | 简单,内存缓存 | 开发测试、单节点 |
| Ehcache | 本地缓存,功能丰富 | 单节点应用 |
| Redis | 分布式缓存,性能好 | 分布式系统、生产环境 |
| Caffeine | 高性能本地缓存 | 单节点,追求性能 |
| Guava Cache | Google 的本地缓存 | 单节点 |
| ...还有很多 |
重点:Spring Cache 是一套抽象,不是具体实现。 你学会了这套注解,不管底层用什么缓存,用法都是一样的。
6.2 Spring Boot 默认缓存:快速入门
我们先从最简单的默认缓存开始,学习缓存注解的用法。
6.2.1 环境准备
步骤 1:创建项目,加依赖
创建 Spring Boot 项目,选择依赖:
- Spring Web
- Spring Cache(spring-boot-starter-cache)
- Spring Data JPA(用来操作数据库)
- MySQL Driver
- Lombok
pom.xml 关键依赖:
<!-- 缓存 starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- JPA(用来操作数据库) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>步骤 2:配置数据库
spring:
datasource:
url: jdbc:mysql://localhost:3306/chapter06?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true步骤 3:开启缓存
在启动类上加 @EnableCaching 注解:
@SpringBootApplication
@EnableCaching // 开启缓存
public class Chapter06Application {
public static void main(String[] args) {
SpringApplication.run(Chapter06Application.class, args);
}
}就这一个注解,缓存就开启了。 不加的话,缓存注解不生效。
步骤 4:准备实体类和 Repository
Book 实体:
@Entity
@Table(name = "book")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Book implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String name;
private String author;
private String press;
private String status;
}注意:实体类要实现 Serializable 接口,因为有些缓存实现需要序列化。 默认缓存不用序列化也行,但养成好习惯。
BookRepository:
@Repository
public interface BookRepository extends JpaRepository<Book, Integer> {
}6.2.2 第一个缓存例子:@Cacheable
我们来写一个查询图书的接口,加上缓存。
BookService:
@Service
public class BookServiceImpl implements BookService {
@Autowired
private BookRepository bookRepository;
/**
* 根据ID查询图书(加缓存)
*/
@Cacheable(cacheNames = "book", key = "#id")
public Book findById(Integer id) {
System.out.println("查询数据库,ID:" + id);
return bookRepository.findById(id).orElse(null);
}
}BookController:
@RestController
@RequestMapping("/book")
public class BookController {
@Autowired
private BookService bookService;
@GetMapping("/findById/{id}")
public Book findById(@PathVariable Integer id) {
return bookService.findById(id);
}
}测试一下:
启动项目,访问 http://localhost:8080/book/findById/1
第一次访问:
- 控制台打印:
查询数据库,ID:1 - 说明走了数据库查询
第二次访问(同一个 ID):
- 控制台没有打印
- 说明没走数据库,直接从缓存拿的
🎉 恭喜!第一个缓存例子跑通了!
@Cacheable 注解的工作流程:
调用方法前 → 先查缓存
↓
缓存有数据?
/ \
是 否
/ \
直接返回 执行方法(查数据库)
↓
结果放入缓存
↓
返回结果6.2.3 @Cacheable 注解详解
@Cacheable 是最常用的缓存注解,标记在方法上,表示这个方法的结果要缓存。
常用属性:
| 属性 | 说明 | 例子 |
|---|---|---|
cacheNames / value | 缓存名称(可以多个) | cacheNames = "book" |
key | 缓存的 key(SpEL 表达式) | key = "#id" |
condition | 缓存条件(满足才缓存) | condition = "#id > 0" |
unless | 不缓存条件(满足就不缓存) | unless = "#result == null" |
keyGenerator | key 生成器 | - |
cacheManager | 缓存管理器 | - |
sync | 是否同步(防止击穿) | sync = true |
cacheNames:缓存名称
缓存的名字,相当于缓存的命名空间。不同的 cacheNames 是相互隔离的。
// 单个缓存名
@Cacheable(cacheNames = "book")
public Book findById(Integer id) { ... }
// 多个缓存名
@Cacheable(cacheNames = {"book", "product"})
public Book findById(Integer id) { ... }key:缓存的 key
缓存的 key,用 SpEL 表达式写。默认是用方法参数生成 key。
// 用参数 id 作为 key
@Cacheable(cacheNames = "book", key = "#id")
public Book findById(Integer id) { ... }
// 用多个参数组合作为 key
@Cacheable(cacheNames = "book", key = "#name + '_' + #author")
public Book findByNameAndAuthor(String name, String author) { ... }
// 用方法名 + 参数作为 key
@Cacheable(cacheNames = "book", key = "#root.methodName + ':' + #id")
public Book findById(Integer id) { ... }key 是缓存的唯一标识,相同 key 的数据会被覆盖。 一般用业务 ID 作为 key 就可以了。
condition:缓存条件
满足条件才缓存,不满足就不缓存。
// 只有 id > 10 才缓存
@Cacheable(cacheNames = "book", key = "#id", condition = "#id > 10")
public Book findById(Integer id) { ... }unless:不缓存条件
满足条件就不缓存,跟 condition 相反。
// 结果为 null 就不缓存
@Cacheable(cacheNames = "book", key = "#id", unless = "#result == null")
public Book findById(Integer id) { ... }condition 是在方法执行前判断,unless 是在方法执行后判断。 所以 unless 可以拿到返回结果(#result),condition 不行。
sync:同步缓存
缓存击穿的时候用,同一个 key 只让一个请求去查数据库,其他的等。
@Cacheable(cacheNames = "book", key = "#id", sync = true)
public Book findById(Integer id) { ... }这个后面讲缓存击穿的时候会细说。
6.2.4 @CachePut:更新缓存
@CachePut 用来更新缓存。方法执行完之后,把结果更新到缓存里。
跟 @Cacheable 的区别:
@Cacheable:先查缓存,有就直接返回,不执行方法@CachePut:每次都执行方法,执行完更新缓存
/**
* 更新图书(同时更新缓存)
*/
@CachePut(cacheNames = "book", key = "#id")
public Book updateById(Integer id, String name) {
Book book = bookRepository.findById(id).orElse(null);
if (book != null) {
book.setName(name);
bookRepository.save(book);
}
return book;
}工作流程:
执行方法(更新数据库)
↓
方法返回结果
↓
把结果更新到缓存
↓
返回结果注意:@CachePut 每次都会执行方法,不管缓存里有没有。 所以一般用在更新操作上。
6.2.5 @CacheEvict:删除缓存
@CacheEvict 用来删除缓存。
/**
* 删除图书(同时删除缓存)
*/
@CacheEvict(cacheNames = "book", key = "#id")
public void delById(Integer id) {
bookRepository.deleteById(id);
}常用属性:
| 属性 | 说明 |
|---|---|
key | 要删除的 key |
allEntries | 是否删除这个缓存下的所有 key |
beforeInvocation | 是否在方法执行前删除 |
删除所有:
// 删除 book 缓存下的所有数据
@CacheEvict(cacheNames = "book", allEntries = true)
public void clearCache() {
// ...
}方法执行前删除:
@CacheEvict(cacheNames = "book", key = "#id", beforeInvocation = true)
public void delById(Integer id) {
// ...
}默认是方法执行后删除。如果方法执行过程中报错了,缓存就不会删。 加 beforeInvocation = true 的话,方法执行前就删,不管方法成功失败都删。
一般用默认的(方法执行后删)就行。 删数据库成功了才删缓存,不然数据库没删掉缓存先删了,就不一致了。
6.2.6 @CacheConfig:类级别配置
如果一个类里的方法都用同一个缓存名,可以在类上加 @CacheConfig,统一配置。
@Service
@CacheConfig(cacheNames = "book") // 类级别配置,所有方法都用 book 这个缓存
public class BookServiceImpl implements BookService {
@Cacheable(key = "#id") // 不用写 cacheNames 了,继承类上的
public Book findById(Integer id) { ... }
@CachePut(key = "#id")
public Book updateById(Integer id, String name) { ... }
@CacheEvict(key = "#id")
public void delById(Integer id) { ... }
}这样类里的方法就不用每个都写 cacheNames = "book" 了。
@CacheConfig 可以配置:
- cacheNames:缓存名称
- keyGenerator:key 生成器
- cacheManager:缓存管理器
- cacheResolver:缓存解析器
6.2.7 @Caching:组合注解
有时候一个操作要同时操作多个缓存,可以用 @Caching 组合多个注解。
@Caching(
cacheable = {
@Cacheable(cacheNames = "book", key = "#id")
},
put = {
@CachePut(cacheNames = "book:name", key = "#result.name")
},
evict = {
@CacheEvict(cacheNames = "book:list", allEntries = true)
}
)
public Book findById(Integer id) {
// ...
}可以组合 @Cacheable、@CachePut、@CacheEvict。
一般用得不多,复杂场景才用。
6.2.8 SpEL 表达式
缓存注解里的 key、condition、unless 都支持 SpEL(Spring Expression Language)表达式。
常用的 SpEL 变量:
| 变量 | 说明 | 例子 |
|---|---|---|
#参数名 | 方法参数 | #id、#name |
#result | 方法返回值(@Cacheable 的 condition 不能用) | #result.name |
#root.method | 方法对象 | #root.method.name |
#root.methodName | 方法名 | #root.methodName |
#root.target | 目标对象 | #root.target |
#root.targetClass | 目标类 | #root.targetClass |
#root.caches | 缓存列表 | #root.caches[0].name |
#root.args | 方法参数数组 | #root.args[0] |
常用运算符:
// 字符串拼接
key = "#id + '_' + #name"
// 算术运算
key = "#id + 100"
// 比较运算
condition = "#id > 10"
// 逻辑运算
condition = "#id > 10 and #name != null"
// 三元运算
unless = "#result == null ? true : false"
// 调用方法
key = "#user.getId()"SpEL 功能很强大,不用全记,常用的记住就行。
6.2.9 默认缓存的特点
Spring Boot 默认用的是 ConcurrentHashMap 作为缓存实现。
优点:
- 简单,不用额外安装
- 开箱即用
- 适合开发测试
缺点:
- 存在内存里,应用重启就没了
- 单节点的,分布式环境下不能共享
- 没有过期时间
- 容量有限,不能存太多
生产环境一般不用默认缓存,要么用 Redis,要么用 Caffeine/Ehcache。
6.3 整合 Ehcache
6.3.1 Ehcache 是什么
Ehcache 是一个纯 Java 的进程内缓存框架,功能比较丰富。
特点:
- 本地缓存,速度快
- 支持持久化(可以存到磁盘)
- 支持过期时间
- 支持淘汰策略(LRU、LFU、FIFO)
- 支持分布式(有 Ehcache 集群方案)
适用场景:
- 单节点应用
- 对性能要求高,不想依赖外部缓存
- 数据量不大,能放内存里
6.3.2 整合步骤
步骤 1:加依赖
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache</artifactId>
</dependency>加了 ehcache 的依赖,Spring Boot 自动就会用 Ehcache 作为缓存实现。 不用改业务代码,注解还是那些注解。
步骤 2:配置文件
在 resources 下创建 ehcache.xml:
<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd">
<!-- 磁盘缓存位置 -->
<diskStore path="java.io.tmpdir/ehcache"/>
<!-- 默认缓存配置 -->
<defaultCache
maxElementsInMemory="10000"
eternal="false"
timeToIdleSeconds="120"
timeToLiveSeconds="120"
maxElementsOnDisk="10000000"
diskPersistent="false"
diskExpiryThreadIntervalSeconds="120"
memoryStoreEvictionPolicy="LRU"/>
<!-- book 缓存配置 -->
<cache name="book"
maxElementsInMemory="1000"
eternal="false"
timeToIdleSeconds="300"
timeToLiveSeconds="600"
overflowToDisk="false"
memoryStoreEvictionPolicy="LRU"/>
</ehcache>配置项说明:
| 配置项 | 说明 |
|---|---|
name | 缓存名称,跟 @Cacheable 的 cacheNames 对应 |
maxElementsInMemory | 内存中最大缓存数量 |
eternal | 是否永久有效(true 的话过期时间没用) |
timeToIdleSeconds | 最大空闲时间(多久没访问就过期) |
timeToLiveSeconds | 最大存活时间(从创建开始算,不管有没有访问) |
overflowToDisk | 内存满了是否写到磁盘 |
diskPersistent | 磁盘持久化(重启还在) |
memoryStoreEvictionPolicy | 内存淘汰策略 |
淘汰策略:
LRU:最近最少使用(最常用)LFU:最不经常使用FIFO:先进先出
步骤 3:指定使用 Ehcache
在 application.yml 里指定缓存类型:
spring:
cache:
type: ehcache
ehcache:
config: classpath:ehcache.xml一般加了依赖自动就识别了,不用特意指定 type。 但显式指定更清楚,推荐写上。
步骤 4:测试
启动项目,跟之前一样测试。 你会发现代码完全不用改,只是底层缓存实现变了。
这就是 Spring Cache 抽象的好处!
6.3.3 Ehcache 小结
优点:
- 本地缓存,速度快
- 功能丰富,支持过期、淘汰、持久化
- 不用额外安装,依赖就行
缺点:
- 本地缓存,分布式环境下不共享
- 数据存在应用内存里,占用堆内存
- 集群环境下数据不一致
Ehcache 适合单节点应用。 现在分布式系统多,一般用 Redis 更多。
6.4 整合 Redis 缓存(重点)
6.4.1 为什么用 Redis 做缓存
Ehcache 是本地缓存,每个节点各存各的,分布式环境下有问题:
- 缓存不共享,每个节点都要查一次数据库
- 缓存不一致,一个节点更新了,其他节点不知道
- 缓存利用率低,重复数据占内存
Redis 是分布式缓存,所有节点共用一个 Redis:
- 缓存共享,查过一次所有节点都能用
- 数据一致
- 支持集群,容量大
- 支持持久化
- 功能丰富
所以生产环境,尤其是分布式系统,一般都用 Redis 做缓存。
6.4.2 整合步骤
步骤 1:加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>步骤 2:配置 Redis
spring:
redis:
host: localhost
port: 6379
password:
database: 0
timeout: 3000ms
cache:
type: redis # 指定用 Redis 缓存
redis:
time-to-live: 3600000 # 默认过期时间,单位毫秒(1小时)
key-prefix: "cache:" # key 前缀
use-key-prefix: true # 是否使用前缀
cache-null-values: false # 是否缓存 null 值缓存配置说明:
| 配置 | 说明 |
|---|---|
time-to-live | 缓存过期时间,默认不过期 |
key-prefix | key 的前缀 |
use-key-prefix | 是否使用前缀 |
cache-null-values | 是否缓存 null 值(防止缓存穿透) |
注意:过期时间是全局的,所有缓存都一样。 如果想给不同的缓存设置不同的过期时间,需要自定义配置。
步骤 3:实体类实现 Serializable
@Entity
@Table(name = "book")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Book implements Serializable { // 必须实现序列化
// ...
}Redis 默认用 JDK 序列化,对象必须实现 Serializable 接口。 不然会报错。
步骤 4:测试
启动项目,访问接口。 然后去 Redis 里看看,是不是有缓存数据了?
用 Redis 客户端(比如 redis-cli)查看:
keys *你应该能看到类似 cache:book::1 这样的 key。
🎉 Redis 缓存整合成功!
6.4.3 自定义 Redis 缓存配置(推荐)
默认的 Redis 缓存用的是 JDK 序列化,存进去是二进制,看着乱码。 而且过期时间是全局的,不能每个缓存单独设置。
一般我们会自定义 Redis 缓存配置,用 JSON 序列化,看着清楚,也更灵活。
配置类:
@Configuration
@EnableCaching
public class RedisCacheConfig {
/**
* 缓存管理器
*/
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
// JSON 序列化器
Jackson2JsonRedisSerializer<Object> jsonSerializer =
new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL
);
jsonSerializer.setObjectMapper(objectMapper);
// String 序列化器
StringRedisSerializer stringSerializer = new StringRedisSerializer();
// 缓存配置
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1)) // 默认过期时间 1 小时
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(stringSerializer)) // key 用 String 序列化
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(jsonSerializer)) // value 用 JSON 序列化
.computePrefixWith(name -> "cache:" + name + ":") // key 前缀
.disableCachingNullValues(); // 不缓存 null
// 不同缓存的单独配置
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
// book 缓存,过期时间 30 分钟
cacheConfigurations.put("book", defaultConfig.entryTtl(Duration.ofMinutes(30)));
// user 缓存,过期时间 1 小时
cacheConfigurations.put("user", defaultConfig.entryTtl(Duration.ofHours(1)));
// 构建缓存管理器
return RedisCacheManager.builder(factory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
}这样配置之后:
- key 是 String 类型,看着清楚
- value 是 JSON 格式,人能看懂
- 不同的缓存可以设置不同的过期时间
- 不缓存 null 值(防止缓存穿透的话可以打开)
实际项目中一般都会自定义配置,用 JSON 序列化。 默认的 JDK 序列化太丑了,也不方便调试。
6.4.4 Redis 缓存的 key 格式
默认情况下,Redis 缓存的 key 格式是:
cache:book::1cache::前缀(我们配置的)book:缓存名称(cacheNames):::分隔符1:key 值
这个格式可以通过配置修改,一般默认的就行。
6.4.5 缓存 null 值与缓存穿透
前面提到了 cache-null-values 这个配置,要不要缓存 null 值呢?
场景:
- 查询一个不存在的 ID
- 缓存没有,查数据库也没有
- 返回 null
- 下次再查,还是缓存没有,又查数据库
- 如果有人恶意用不存在的 ID 疯狂请求,数据库就挂了
这就是缓存穿透。
解决方案之一:缓存 null 值
把不存在的查询结果也缓存起来(存个 null),下次直接返回 null,不用查数据库。
spring:
cache:
redis:
cache-null-values: true # 缓存 null 值优点:
- 简单,配置一下就行
- 能解决缓存穿透
缺点:
- 占空间(存了很多 null)
- 如果这个 key 之后真的有数据了,缓存里还是 null,要等过期
一般建议打开,尤其是查询接口比较多的系统。 过期时间设短一点就行。
6.5 缓存进阶:常见问题与解决方案
缓存不是银弹,用不好会有很多问题。我们来看看常见的几个问题。
6.5.1 缓存穿透
什么是缓存穿透?
查询一个一定不存在的数据,缓存没有,数据库也没有。 每次请求都打到数据库,缓存形同虚设。
如果有人恶意用不存在的 key 疯狂请求,数据库压力就很大,甚至挂掉。
请求 → 缓存没有 → 查数据库 → 数据库也没有 → 返回 null
↑ ↓
└────────────────────────────────────────┘
每次都这样,缓存没用解决方案:
方案一:缓存 null 值
把不存在的也缓存起来,存个 null。 下次再查,直接返回 null,不用查数据库。
spring:
cache:
redis:
cache-null-values: true优点:简单,配置就行 缺点:占空间,数据新增时有短暂不一致
方案二:布隆过滤器(Bloom Filter)
布隆过滤器是一种数据结构,可以快速判断"某个数据一定不存在"。
把所有存在的 key 都放到布隆过滤器里。 请求过来先过布隆过滤器,如果过滤器说不存在,直接返回,不用查缓存和数据库。
优点:空间效率高 缺点:有一定误判率(说存在的可能不存在,但说不存在的一定不存在),实现稍复杂
一般场景用缓存 null 值就够了。 数据量特别大、攻击风险高的场景用布隆过滤器。
6.5.2 缓存击穿
什么是缓存击穿?
某个热点 key,在缓存过期的瞬间,大量并发请求同时过来。 缓存没了,所有请求都打到数据库,数据库压力骤增。
热点 key 过期
↓
大量请求同时到达
↓
缓存都没有
↓
所有请求都去查数据库
↓
数据库压力山大跟穿透的区别:
- 穿透:数据不存在
- 击穿:数据存在,只是缓存刚好过期了
解决方案:
方案一:互斥锁
缓存失效的时候,只让一个请求去查数据库,其他请求等着。 查完把缓存放好,其他请求直接从缓存拿。
Spring Cache 的 sync = true 就是干这个的:
@Cacheable(cacheNames = "book", key = "#id", sync = true)
public Book findById(Integer id) {
// ...
}优点:简单,效果好 缺点:会阻塞其他请求
方案二:热点数据永不过期
特别热的 key,不设过期时间,永远不过期。 后台异步更新缓存。
优点:不会有击穿问题 缺点:数据更新不及时,需要额外的更新机制
一般用互斥锁方案就够了,Spring Cache 自带支持。
6.5.3 缓存雪崩
什么是缓存雪崩?
大量 key 在同一时间过期,或者 Redis 挂了,导致所有请求都打到数据库。 数据库承受不住,就挂了。
大量 key 同时过期 / Redis 宕机
↓
所有请求都打数据库
↓
数据库挂了跟击穿的区别:
- 击穿:一个热点 key 过期
- 雪崩:大量 key 同时过期,或者 Redis 挂了
解决方案:
针对大量 key 同时过期:
方案一:过期时间加随机值
给每个 key 的过期时间加一个随机数,避免同时过期。
// 基础时间 1 小时 + 随机 0~10 分钟
int ttl = 3600 + new Random().nextInt(600);方案二:缓存预热
系统启动的时候,把热点数据提前加载到缓存里。 不用等用户请求才加载。
针对 Redis 挂了:
方案一:Redis 集群
搞 Redis 集群,一主多从,哨兵模式或者集群模式。 一个挂了还有其他的,高可用。
方案二:服务降级 / 熔断
Redis 挂了的时候,降级处理,比如直接返回默认值、或者查本地缓存。 不要让所有请求都打到数据库。
生产环境 Redis 一定要做集群,保证高可用。 单机 Redis 是有单点风险的。
6.5.4 缓存与数据库一致性
问题: 缓存和数据库的数据不一致怎么办?
比如:更新了数据库,但缓存没更新,或者更新失败了。
Cache Aside 模式(推荐):
这是最常用的模式:
读:
1. 先读缓存,有就返回
2. 没有就读数据库
3. 数据库结果放入缓存
4. 返回
写:
1. 先更新数据库
2. 再删除缓存(注意:是删除,不是更新)为什么是删除缓存,不是更新缓存?
- 并发安全:如果两个写请求同时来,更新缓存可能导致脏数据
- 简单:删了就行,下次读的时候再加载
- 懒加载:不用的数据不用更新,省空间
为什么是先更数据库,再删缓存?
如果先删缓存,再更数据库:
- 删完缓存,还没更数据库的时候,有个读请求过来
- 读请求发现缓存没了,查数据库(旧数据),放到缓存
- 然后数据库更新了
- 结果:缓存里是旧数据,数据库是新数据 → 不一致
先更数据库,再删缓存:
- 更完数据库,还没删缓存的时候,有个读请求过来
- 读请求从缓存拿到旧数据
- 然后缓存被删了
- 下次读就是新数据了
- 结果:只有短暂的不一致,最终是一致的
这是业界的最佳实践:先更数据库,再删缓存。 保证最终一致性,不是强一致性。 大部分业务场景,最终一致就够了。
Spring Cache 的 @CachePut 是更新缓存,不是删除。 那是不是不符合最佳实践?
确实,@CachePut 是更新缓存。 但对于单表、简单场景,用 @CachePut 也没问题,因为是在同一个事务里。
复杂场景、多表更新的,建议自己控制缓存,手动删。
6.5.5 缓存预热
什么是缓存预热?
系统刚启动的时候,缓存是空的。 如果一下子有大量请求过来,都打在数据库上,可能扛不住。
缓存预热就是:系统启动的时候,提前把热点数据加载到缓存里。
怎么做:
- 启动完成后,自动加载热点数据
- 用 @PostConstruct 或者 ApplicationRunner
- 或者定时任务,定期刷新热点缓存
例子:
@Component
public class CachePreLoader implements ApplicationRunner {
@Autowired
private BookService bookService;
@Override
public void run(ApplicationArguments args) {
// 预热热点图书
System.out.println("开始预热缓存...");
List<Integer> hotIds = Arrays.asList(1, 2, 3, 4, 5);
for (Integer id : hotIds) {
bookService.findById(id);
}
System.out.println("缓存预热完成");
}
}不是所有项目都需要缓存预热。 数据量不大、流量不高的项目,不用也行。 大流量、高并发的系统,建议做缓存预热。
6.6 企业最佳实践
6.6.1 缓存使用原则
- 能不用就不用:缓存增加了系统复杂度,不是必须的就别加
- 先考虑一致性要求:强一致性的别用缓存,或者用其他方案
- 热点数据才缓存:不常访问的数据缓存了也没用,还占空间
- 设置合理的过期时间:不要太长(不一致风险大),也不要太短(命中率低)
- 监控缓存命中率:命中率低的话,缓存就没意义了
6.6.2 缓存粒度控制
缓存什么?
- 不要什么都缓存,只缓存热点的、读多写少的
- 粒度要合适,不要太大也不要太小
缓存粒度选择:
- 对象级缓存:缓存整个对象(比如用户信息)
- 列表级缓存:缓存列表数据
- 页面级缓存:缓存整个页面(很少用)
一般用对象级缓存比较多。
6.6.3 缓存命名规范
key 的命名要规范,方便管理和排查问题。
建议格式:
业务名:模块名:唯一标识例子:
cache:user:1001cache:book:1cache:product:list:page1_size10
好处:
- 一看就知道是哪个业务的
- 方便批量删除(比如按前缀删)
- 避免 key 冲突
6.6.4 缓存监控
生产环境一定要监控缓存:
- 命中率:命中率低说明缓存没效果
- QPS:缓存的访问量
- 内存使用:别把内存占满了
- 过期 key 数量
- 大 key:别存太大的 value
Redis 有很多监控工具,比如 RedisInsight、Prometheus + Grafana 等。
6.6.5 缓存降级
Redis 挂了怎么办?不能整个系统都挂了。
降级方案:
- 本地缓存兜底:Redis 挂了,用本地缓存(比如 Caffeine)
- 直接查数据库:但要限流,别把数据库打挂了
- 返回默认值:非核心数据,返回默认值或者空
高可用系统一定要考虑降级方案。 不能因为缓存挂了,整个系统就瘫了。
6.7 新手常见问题排查
问题 1:缓存不生效
症状:加了 @Cacheable 注解,但每次都查数据库,缓存没生效。
可能原因:
- 没加 @EnableCaching
- 注解加在 private 方法上(Spring AOP 不支持 private 方法)
- 同类内部调用(同一个类里的方法调用,AOP 不生效)
- key 不对,每次 key 都不一样
- 异常了,缓存没写入
排查:
- 检查启动类有没有 @EnableCaching
- 方法是不是 public 的
- 是不是同类内部调用(是的话要自己注入自己,或者用 AopContext)
- 打印一下 key 看看对不对
问题 2:Redis 里的 key 是乱码
症状:Redis 里的 key 和 value 都是 \xAC\xED... 这种乱码
原因:默认用的 JDK 序列化
解决:自定义 RedisCacheConfiguration,用 String 和 JSON 序列化
问题 3:缓存和数据库不一致
症状:数据库更新了,但缓存里还是旧数据
可能原因:
- 更新的时候没删缓存
- 删缓存失败了
- 并发场景下的一致性问题
- 过期时间太长
解决:
- 确保更新操作加了 @CacheEvict 或者 @CachePut
- 检查删除缓存有没有成功
- 用 Cache Aside 模式(先更数据库,再删缓存)
- 设置合理的过期时间,保证最终一致
问题 4:@Cacheable 的 condition 不生效
症状:condition 写了,但好像没起作用
可能原因:
- SpEL 表达式写错了
- condition 里用了 #result(condition 是方法执行前判断的,拿不到返回值)
解决:
- 检查 SpEL 表达式
- 要根据返回值判断的话,用 unless,不要用 condition
问题 5:同类内部调用缓存不生效
症状:同一个类里,方法 A 调用方法 B,B 上的缓存注解不生效
原因:Spring Cache 是基于 AOP 的,同类内部调用不走代理,注解不生效
解决:
- 把方法放到不同的类里(推荐)
- 自己注入自己(@Autowired 注入自己,然后用注入的对象调用)
- 用 AopContext.currentProxy() 获取代理对象
最好的办法是把缓存方法单独放一个类里,结构更清晰。
问题 6:Redis 连接失败
症状:启动报错,连不上 Redis
可能原因:
- Redis 没启动
- 地址端口不对
- 密码不对
- 防火墙没开
- 配置了 type: redis 但没装 Redis
解决:
- 确认 Redis 启动了
- 检查配置
- 测试连通性
6.8 本章小结
恭喜你!缓存这一章学完了!
你都学了什么
缓存基础:
- 什么是缓存、为什么需要缓存
- 缓存的适用场景
- Spring Boot 缓存抽象
声明式缓存注解(重点):
- @EnableCaching:开启缓存
- @Cacheable:查询缓存
- @CachePut:更新缓存
- @CacheEvict:删除缓存
- @CacheConfig:类级别配置
- @Caching:组合注解
- SpEL 表达式
缓存实现:
- 默认缓存(ConcurrentHashMap)
- 整合 Ehcache
- 整合 Redis 缓存(重点)
- 自定义 Redis 缓存配置(JSON 序列化、过期时间)
缓存进阶:
- 缓存穿透:原因、解决方案(缓存 null、布隆过滤器)
- 缓存击穿:原因、解决方案(互斥锁、永不过期)
- 缓存雪崩:原因、解决方案(随机过期、集群、降级)
- 缓存与数据库一致性:Cache Aside 模式
- 缓存预热
企业最佳实践:
- 缓存使用原则
- 缓存粒度控制
- 命名规范
- 缓存监控
- 缓存降级
动手实践清单
| 实践项 | 做完打勾 |
|---|---|
| 能开启 Spring Cache(@EnableCaching) | ☐ |
| 会用 @Cacheable 加缓存 | ☐ |
| 会用 @CachePut 更新缓存 | ☐ |
| 会用 @CacheEvict 删除缓存 | ☐ |
| 会用 @CacheConfig 类级别配置 | ☐ |
| 理解 SpEL 表达式在缓存中的用法 | ☐ |
| 能整合 Ehcache | ☐ |
| 能整合 Redis 缓存 | ☐ |
| 会自定义 Redis 缓存配置(JSON 序列化) | ☐ |
| 理解缓存穿透、击穿、雪崩及解决方案 | ☐ |
| 理解 Cache Aside 模式 | ☐ |
| 能排查缓存常见问题 | ☐ |
面试常问
Spring Cache 常用的注解有哪些?分别什么作用?
- @EnableCaching:开启缓存
- @Cacheable:查询缓存,有就直接返回
- @CachePut:更新缓存,每次都执行方法
- @CacheEvict:删除缓存
- @CacheConfig:类级别统一配置
@Cacheable 和 @CachePut 的区别?
- @Cacheable:先查缓存,有就不执行方法
- @CachePut:每次都执行方法,执行完更新缓存
- 一个用于查询,一个用于更新
缓存穿透、击穿、雪崩分别是什么?怎么解决?
- 穿透:查不存在的数据 → 缓存 null 值、布隆过滤器
- 击穿:热点 key 过期 → 互斥锁、永不过期
- 雪崩:大量 key 同时过期 / Redis 挂了 → 随机过期、集群、降级
缓存和数据库怎么保证一致性?
- Cache Aside 模式:先更数据库,再删缓存
- 最终一致性,不是强一致
- 设置过期时间兜底
为什么是删除缓存,不是更新缓存?
- 并发场景下更新缓存可能有脏数据
- 删除更简单,懒加载
- 不用的数据不用更新,省空间
Spring Cache 支持哪些缓存实现?
- 默认(ConcurrentHashMap)
- Ehcache
- Redis
- Caffeine
- Guava
- 等等
Redis 缓存和本地缓存(Ehcache/Caffeine)怎么选?
- 单节点、数据量小:本地缓存,速度更快
- 分布式系统、数据量大:Redis,共享、可扩展
- 也可以组合用:本地缓存做一级,Redis 做二级
给实习生的建议
先掌握基本用法:几个核心注解先搞明白,能用上。
理解原理很重要:缓存的问题(穿透、击穿、雪崩)是面试必问的,一定要理解。
不要滥用缓存:不是什么都要缓存。加了缓存,就多了一层复杂度,多了一致性问题。
注意数据一致性:用缓存就要接受最终一致。强一致的场景别用缓存。
线上操作要小心:别乱清缓存,尤其是生产环境。清缓存可能导致数据库压力骤增。
做好监控:缓存命中率、内存使用这些都要监控,出问题能及时发现。
下一章我们学习 Spring Security,安全管理。加油!